Context

Critiques

  • (2025-12-12) I highly recommend checking out this discord thread in the Odin server, where there were 270+ answers from passionate people about the topic.

  • Many defended context, where some admitted not enjoying the experience of having to work with it. Overall, I learned a lot and changed my mind about user_ptr  and user_index, but couldn’t wrap my head around the context.allocator / context.temp_allocator , as I see this as a solution to the problem of implicit allocations in the language, as well as context.logger  / context.assertion_failure_proc  / context.random_generator , as I do believe they would be a better fit as thread-local or global variables.

  • One day after having this discussion (2025-12-13) I decided to create my own fork  of Odin and experiment with different design choices. The README.md of the repo goes on explaining the changes I made to the language and what the target of this fork is; it's very exploratory. I cover a lot of the discussed topics in my fork, testing approaches for the designs I've proposed.

    • (2026-01-22) Quite update: I'm very much enjoying working with my fork, no complains so far, I feel much more confident with the language.

  • This article  was written right after the discussion in the discord thread.

    • "You could argue that it is “better” to pass allocators around explicitly, but from my own experience in C with this exact interface (made and used well before I even made Odin), I found that I got in a very very lazy habit of not actually passing around allocators properly. This overly explicitness with a generalized interface lead to more allocation bugs than if I had used specific allocators on a per-system basis."

      • context.allocator  is used to solve a problem that his way of programming created. If the language was not designed around implicit and unsafe allocations with allocator := , then this wouldn't be necessary. If, for example, make()   required  an allocator, while context.allocator  didn't exist, you wouldn't have any other choice but to use an allocator passed as an argument in the procedure.

My Rant

  • (2025-12-12)

  • I'm not a fan of context at all .

  • To even print you need to have a context  defined.

  • This doesn't compile:

    // "contextless" procedure
    {
        fmt.printfln("TEST")
    }
    
  • This works just fine .

    // "contextless" procedure
    {
        context = runtime.default_context()
        context.allocator      = mem.panic_allocator()
        context.temp_allocator = mem.panic_allocator()
        fmt.printfln("TEST")
    }
    
  • It really  doesn't matter if everything is not even initialized, or the allocators are defined to panic_allocator .

  • Let's go through the checklist:

    • Why do I NEED to define a context ?

      • Because the proc is defined with the :: proc()  calling convention, indicating that it NEEDS a context .

    • Why does it NEED a context ?

      • Because internally fmt.printfln  calls wprintf , which uses assert , and assert  requires a context .

      • A LOT of the other procedures inside the chain are defined with the :: proc()  calling convention without even needing it.

      • In this chain, ONLY assert  requires context , and no other procedure.

    • Why does assert  NEED context ?

      • So it can "print as user configured" by the context.assertion_failure_proc .

    • Why do we NEED a context.assertion_failure_proc ?

      • We don't.

      • The purpose of this assertion procedure is to "use the same assert procedure as configured by the user":

        assert :: proc(condition: bool, message := #caller_expression(condition), loc := #caller_location) {
            if !condition {
                @(cold)
                internal :: proc(message: string, loc: Source_Code_Location) {
                    p := context.assertion_failure_proc
                    if p == nil {
                        p = default_assertion_failure_proc
                    }
                    p("runtime assertion", message, loc)
                }
                internal(message, loc)
            }
        }
        
      • But the idea doesn't actually work a lot of the time, as this happens:

        • base:runtime/default_temp_allocator_arena.odin

          • This is not using the "assertion procedure" defined by the USER, just the default one.

          context = default_context()
          context.allocator = allocator
          mem_free(block_to_free, allocator, loc)
          
        • base:runtime/print.odin

          • This is not using the "assertion procedure" defined by the USER, just the default one.

          println_any :: #force_no_inline proc "contextless" (args: ..any) {
              context = default_context()
              loop: for arg, i in args {
                  assert(arg.id != nil)
                  if i != 0 {
                      print_string(" ")
                  }
                  print_any_single(arg)
              }
              print_string("\n")
          }
          
      • There are a lot of uses of runtime.default_context()  in the base  and core  library, while it's also suggested  to use runtime.default_context()  for "c"  and "contextless"  calling conventions. Every time you do it, you lose the reason for context  being invented in the first place.

      • "The main purpose of the implicit context system is for the ability to intercept third-party code and libraries and modify their functionality. One such case is modifying how a library allocates something or logs something" - implicit context system .

      • Expect when this is not the case.

    • Could this function be contextless ?

      • Yes.

      • assert_contextless  already solves this by:

        assert_contextless :: proc "contextless" (condition: bool, message := #caller_expression(condition), loc := #caller_location) {
            if !condition {
                @(cold)
                internal :: proc "contextless" (message: string, loc: Source_Code_Location) {
                    default_assertion_contextless_failure_proc("runtime assertion", message, loc)
                }
                internal(message, loc)
            }
        }
        
      • But if you want customization defined by the user, just:

        assert_contextless :: proc "contextless" (condition: bool, message := #caller_expression(condition), loc := #caller_location) {
            if !condition {
                @(cold)
                internal :: proc "contextless" (message: string, loc: Source_Code_Location) {
                    if global_assertion_failure_procedure_defined_by_the_user != nil {
                        global_assertion_failure_procedure_defined_by_the_user("runtime assertion", message, loc)
                    } else {
                        default_assertion_contextless_failure_proc("runtime assertion", message, loc)
                    }
                }
                internal(message, loc)
            }
        }
        
        main :: proc() {
            runtime.global_assertion_failure_procedure_defined_by_the_user = my_assertion_procedure
        }
        
        • Different from the way context  is used, in this case you ACTUALLY get the user-defined assertion procedure, with a fallback if not defined.

        • With context  the code may or may not use the assertion procedure defined by you, but with this code above, your assertion procedure will ALWAYS be used, WITHOUT NEEDING A CONTEXT!!

  • Fun fact, assert  doesn't actually care if the context.assertion_failure_proc  was defined. If not defined, it just falls back to the default_assertion_failure_proc :

    assert :: proc(condition: bool, message := #caller_expression(condition), loc := #caller_location) {
        if !condition {
            @(cold)
            internal :: proc(message: string, loc: Source_Code_Location) {
                p := context.assertion_failure_proc
                if p == nil {      // <-- note here
                    p = default_assertion_failure_proc
                }
                p("runtime assertion", message, loc)
            }
            internal(message, loc)
        }
    }
    
  • So the first snippet is technically the same as the one below:

    {
        context = {}
        fmt.printfln("TEST")
    }
    
  • "Ok, but in this case the allocators are not panic_allocator s, just "nil" ( { data = nil, procedure = nil } ), so this might crash if you try to allocate right?"

    • Nope. If allocator.procedure == nil , it just doesn't allocate without returning any errors. You might not even realize you don't have a context.allocator  and context.temp_allocator  defined. You'll get a nil pointer without returned errors. The code just silently allows this.

      mem_alloc_bytes :: #force_no_inline proc(size: int, alignment: int = DEFAULT_ALIGNMENT, allocator := context.allocator, loc := #caller_location) -> ([]byte, Allocator_Error) {
          assert(is_power_of_two_int(alignment), "Alignment must be a power of two", loc)
          if size == 0 || allocator.procedure == nil {
              return nil, nil
          }
          return allocator.procedure(allocator.data, .Alloc, size, alignment, nil, 0, loc)
      }
      
    • This is a whole different discussion about explicitness, but I thought I mentioned.

  • My point is:

    • A lot of procedures REQUIRE context  when they shouldn't. They don't actually need it and it just creates bloated and visually confusing code.

    • I believe that ALL fields from the context  could be defined as thread local global variables customizable by the user, and :: proc()  should be contextless  by default, while having the whole context  system removed.

    • "But what about context.allocator  and context.temp_allocator  that are so used around all the libs?"

      • Instead of context.allocator , just use runtime.allocator .

      • Instead of context.temp_allocator , just use runtime.temp_allocator .

      • Both runtime.allocator  and runtime.temp_allocator  would be allocators automatically initiated right before _startup_runtime() , just like runtime.default_context()  does, but this time without compromising all libraries by demanding that context  be used.

    • "What about logger??"

      • Same idea, instead of context.logger , just use log.logger .

    • "What if I want something only for a scope, to then go back to the previous thing?"

      context.allocator  = runtime.heap_allocator()
      context.user_index = 456
      {
          context.allocator  = my_custom_allocator()
          context.user_index = 123
      }
      assert(context.user_index == 456)
      
      • First off, this is weird. I don't think it's obvious for anyway at first how context.user_index == 456  when it was just defined as 123  2 lines above.

      • Secondly, if you really  want a scope thing, just do:

      allocator := runtime.heap_allocator()
      {
          scope(&allocator, {})
      
          assert(allocator == {})
      }
      assert(allocator == runtime.heap_allocator())
      
      @(deferred_out=_scope_thingy_end)
      scope :: proc(old_value: ^mem.Allocator, new_value: mem.Allocator) -> (old_value_out: ^mem.Allocator, previous_value: mem.Allocator) {
          previous_value = old_value^
          old_value^ = new_value
          return old_value, previous_value
      }
      
      _scope_end :: proc(old_value: ^mem.Allocator, previous_value: mem.Allocator) {
          old_value^ = previous_value
      }
      
      • It would be useful if we could use polymorphic parameters with deferred_out , but I don't really mind as I never used context  this way anyway.

      • I mean, it's really just an auxiliary variable, it shouldn't be that big of a problem. At least the variable changing when exiting the scope is much more obvious than the implicit way context  does.

    • "Finally, what about cache locality?"

      • I'm not completely sure about this one. I imagine that many of the fields inside context wouldn't care that much, as there's an indirection inside every allocator, logger, etc, but that would have to be profiled. Anyway, I would imagine it pays off for not having to carry a ~196 bytes  struct around for every function call.

      • Laytan:

        • We've done a test to determine if thread local context is faster than passing it as a param and found the difference negligible.

Usages

  • This is the usages I could find by ctrl+shift+F  on the whole Odin repository (base, core, vendor):

    Context :: struct {
        allocator:              Allocator,
            // Everywhere.
        temp_allocator:         Allocator,
            // Everywhere.
        
        assertion_failure_proc: Assertion_Failure_Proc,
            // Used in `assert`, `panic`, `ensure`, `unimplemented`.
            // Used in `fmt` as: `assertf`, `panicf`, `ensuref`.
            // Used in `log` as: `assert`, `assertf`, `ensure`, `ensuref`.
        
        random_generator:       Random_Generator, 
            // Used in `math/rand`, `encoding/uuid`
        
        logger:                 Logger,           
            // `core:log` is imported for `core:text/table`, `vendor:fontstash`, `vendor:nanovg/gl`.
            // `context.logger` is used directly only once in `core:mem` (doesn't make any sense, tbh).
            
        user_ptr:               rawptr,           
            // Not used anywhere.
        user_index:             int,              
            // Not used anywhere.
        _internal:              rawptr,           
            // Not used anywhere, except in 1 Cpp script.
    }
    

context.allocator

  • For “general” allocations, for the subsystem it is used within.

  • Is an OS heap allocator .

context.temp_allocator

  • For temporary and short lived allocations, which are to be freed once per cycle/frame/etc.

  • Assigned to a scratch allocator  (a growing arena based allocator).

Init

  • base:runtime  -> core.odin

@private
__init_context :: proc "contextless" (c: ^Context) {
    if c == nil {
        return
    }
    // NOTE(bill): Do not initialize these procedures with a call as they are not defined with the "contextless" calling convention
    c.allocator.procedure = default_allocator_proc
    c.allocator.data = nil
    
    c.temp_allocator.procedure = default_temp_allocator_proc
    when !NO_DEFAULT_TEMP_ALLOCATOR {
        c.temp_allocator.data = &global_default_temp_allocator_data
    }
    
    when !ODIN_DISABLE_ASSERT {
        c.assertion_failure_proc = default_assertion_failure_proc
    }
    
    c.logger.procedure = default_logger_proc
    c.logger.data = nil
    
    c.random_generator.procedure = default_random_generator_proc
    c.random_generator.data = nil
}

Threading

  • A new context is created using runtime.default_context()  if not context is specified when calling thread.create_and_start .

  • The new context will maybe  clean up its context.temp_allocator .

    • Tetra, 2023-05-31:

      • If the user specifies a custom context for the thread, then it's entirely up to them to handle whatever allocators they're using.

// core:thread
_select_context_for_thread :: proc(init_context: Maybe(runtime.Context)) -> runtime.Context {
    ctx, ok := init_context.?
    if !ok {
        return runtime.default_context()
    }
    /*
        NOTE(tetra, 2023-05-31):
            Ensure that the temp allocator is thread-safe when the user provides a specific initial context to use.
            Without this, the thread will use the same temp allocator state as the parent thread, and thus, bork it up.
    */
    when !ODIN_DEFAULT_TO_NIL_ALLOCATOR {
        if ctx.temp_allocator.procedure == runtime.default_temp_allocator_proc {
            ctx.temp_allocator.data = &runtime.global_default_temp_allocator_data
        }
    }
    return ctx
}

// core:thread
_maybe_destroy_default_temp_allocator :: proc(init_context: Maybe(runtime.Context)) {
    if init_context != nil {
        // NOTE(tetra, 2023-05-31): If the user specifies a custom context for the thread,
        // then it's entirely up to them to handle whatever allocators they're using.
        return
    }
    if context.temp_allocator.procedure == runtime.default_temp_allocator_proc {
        runtime.default_temp_allocator_destroy(auto_cast context.temp_allocator.data)
    }
}

// core/thread/thread_windows.odin:41 / core/thread/thread_unix.odin:54
_create :: proc(procedure: Thread_Proc, priority: Thread_Priority) -> ^Thread {
    // etc
    {
        context = _select_context_for_thread(init_context)
        defer {
            _maybe_destroy_default_temp_allocator(init_context)
            runtime.run_thread_local_cleaners()
        }
        t.procedure(t)
    }
    //etc
}